#!/usr/bin/env python

import os
import re
import shutil
import sys
import stat
import copy

### use get_version() ?
MAJOR = 1

# Basic defines for our outputs.
LIBNAME = 'libserf-%d' % (MAJOR,)
INCLUDES = 'serf-%d' % (MAJOR,)
PCFILE = 'serf-%d' % (MAJOR,)


FILES_HDR = [
  ('.', 'serf'),
  ('.', 'serf_bucket_types'),
  ('.', 'serf_bucket_util'),
  ]

LIB_FILES = [
  ('.', 'context'),
  ('.', 'incoming'),
  ('.', 'outgoing'),
  ('.', 'ssltunnel'),

  ('buckets', 'aggregate_buckets'),
  ('buckets', 'request_buckets'),
  ('buckets', 'buckets'),
  ('buckets', 'simple_buckets'),
  ('buckets', 'file_buckets'),
  ('buckets', 'mmap_buckets'),
  ('buckets', 'socket_buckets'),
  ('buckets', 'response_buckets'),
  ('buckets', 'headers_buckets'),
  ('buckets', 'allocator'),
  ('buckets', 'dechunk_buckets'),
  ('buckets', 'deflate_buckets'),
  ('buckets', 'limit_buckets'),
  ('buckets', 'ssl_buckets'),
  ('buckets', 'barrier_buckets'),
  ('buckets', 'chunk_buckets'),
  ('buckets', 'iovec_buckets'),
  ('auth', 'auth'),
  ('auth', 'auth_basic'),
  ('auth', 'auth_digest'),
  ('auth', 'auth_kerb'),
  ('auth', 'auth_kerb_gss'),
  ]

TEST_DEPS = [
  ('test', 'CuTest'),
  ('test', 'test_util'),
  ('test', 'test_context'),
  ('test', 'test_buckets'),
  ('test', 'test_ssl'),
  ('test/server', 'test_server'),
  ]

TEST_HDR_FILES = [
  ('test', 'CuTest'),
  ('test', 'test_serf'),
  ]

TEST_FILES = [
  ('test', 'serf_get'),
  ('test', 'serf_response'),
  ('test', 'serf_request'),
  ('test', 'serf_spider'),
  ('test', 'test_all'),
  ]

TESTCASES = [
  ('test/testcases', 'simple.response'),
  ('test/testcases', 'chunked-empty.response'),
  ('test/testcases', 'chunked.response'),
  ('test/testcases', 'chunked-trailers.response'),
  ('test/testcases', 'deflate.response'),
  ]


def main(argv):
  params = {}

  commands = []

  for arg in argv[1:]:
    idx = arg.find('=')
    if idx > 0:
      start = arg.rfind('-', 0, idx)
      if start > 0:
        params[arg[start+1:idx]] = arg[idx+1:].strip()
    else:
      func = globals().get('cmd_' + arg)
      if func:
        commands.append(func)
      else:
        print('ERROR: unknown argument: ' + arg)
        usage()

  if not commands:
    usage()

  for func in commands:
    try:
      func(params)
    except:
      print('ERROR: exception:')
      print(sys.exc_info()[1])
      print("")
      usage()


def usage():
  ### print something
  print('serfmake [cmd] [options]')
  print('Commands:')
  print('\tbuild\tBuilds (default)')
  print('\tcheck\tRuns test cases')
  print('\tinstall\tInstalls serf into PREFIX')
  print('\tclean\tCleans')
  print('Options:')
  print('\t--with-apr=PATH\t\tprefix for installed APR and APR-util')
  print('\t\t\t\t(needs apr-1-config and apu-1-config; will look in PATH)')
  print('\t--with-gssapi=PATH\tbuild serf with GSSAPI support')
  print('\t\t\t\t(needs krb5-config; will look in PATH/bin)')

  print('\t--prefix=PATH\t\tinstall serf into PATH (default: /usr/local)')
  print('Quick guide:')
  print('\tserfmake --prefix=/usr/local/serf --with-apr=/usr/local/apr install')
  sys.exit(1)


def cmd_build(param):
  builder = Builder(param)
  builder.build_target(File('.', LIBNAME, 'la'), False)
  builder.build_target(File('.', PCFILE, 'pc'), False)


def cmd_install(param):
  builder = Builder(param)
  ### should be called .install_all()
  builder.install_target(File('.', LIBNAME, 'la'), False)


def cmd_check(param):
  builder = Builder(param)
  for dirpath, fname in TEST_FILES:
    builder.build_target(File(dirpath, fname, None), False)

  for dirpath, fname in TESTCASES:
    case = os.path.join(dirpath, fname)
    print('== Testing %s ==' % case)
    result = os.system('%s %s' % (os.path.join('test', 'serf_response'), case))
    if result:
      raise TestError("", result)

  # run the test suite based on the CuTest framework
  result = os.system(os.path.join('test', 'test_all'))
  if result:
    raise TestError(case, result)  

def cmd_clean(param):
  targets = [File(dirpath, fname, 'o') for dirpath, fname in LIB_FILES]
  targets += [File(dirpath, fname, 'lo') for dirpath, fname in LIB_FILES]
  targets += [File('.', LIBNAME, 'la'),
              File('.', PCFILE, 'pc'),
              ]
  targets += [File(dirpath, fname, 'o') for dirpath, fname in TEST_FILES]
  targets += [File(dirpath, fname, 'lo') for dirpath, fname in TEST_FILES]
  targets += [File(dirpath, fname, None) for dirpath, fname in TEST_FILES]
  targets += [File(dirpath, fname, 'o') for dirpath, fname in TEST_DEPS]
  targets += [File(dirpath, fname, 'lo') for dirpath, fname in TEST_DEPS]

  clean = [file for file in targets if file.mtime]
  if clean:
    sys.stdout.write('Cleaning %d files... ' % len(clean))
    for i in clean:
      if i.mtime:
        os.remove(i.fname)
    print('done.')
  else:
    print('Clean.')


class Builder(object):
  def __init__(self, params):
    # use apr option if set
    if 'apr' in params:
      self.apr = APRConfig(params['apr'])
      self.apu = APUConfig(params['apr'])
    else:
      self.apr = APRConfig(None)
      self.apu = APUConfig(None)

    # build with gssapi if option is set
    if 'gssapi' in params:
      self.gssapi = GSSAPIConfig(params['gssapi'])
    else:
      self.gssapi = None

    try:
      self.prefix = params['prefix']
    except:
      self.prefix = '/usr/local'

    ### no way to tweak these
    self.libdir = os.path.join(self.prefix, 'lib')
    self.pkgconfigdir = os.path.join(self.prefix, 'lib', 'pkgconfig')
    self.includedir = os.path.join(self.prefix, 'include', INCLUDES)

    self.load_vars()
    self.load_deps()

  def load_vars(self):
    self.CC = self.apr.get_value('CC', '--cc')
    self.CFLAGS = self.apr.get_value('CFLAGS', '--cflags')
    self.CPPFLAGS = self.apr.get_value('CPPFLAGS', '--cppflags')
    self.LIBTOOL = self.apr.get_value('LIBTOOL', '--apr-libtool')
    self.LDFLAGS = self.apr.get_value('LDFLAGS', '--ldflags') \
                   + ' ' + self.apu.get_value('LDFLAGS', '--ldflags')

    self.INCLUDES = '-I%s -I%s -I%s' % (
      '.',
      self.apr.get_value(None, '--includedir'),
      self.apu.get_value(None, '--includedir'),
      )
    if os.getenv('EXTRA_INCLUDES'):
      self.INCLUDES += ' -I' + os.getenv('EXTRA_INCLUDES')

    self.LIBS = self.apu.get_value(None, '--link-libtool') \
                + ' ' + self.apu.get_value(None, '--libs') \
                + ' ' + self.apr.get_value(None, '--link-libtool') \
                + ' ' + self.apr.get_value(None, '--libs') \
                + ' -lz'
    self.SSL_LIBS = '-lssl -lcrypto'
    if self.gssapi:
        self.LIBS += ' ' + self.gssapi.get_value(None, '--libs gssapi')
        self.CFLAGS += ' ' + self.gssapi.get_value('CFLAGS', '--cflags gssapi')\
                       + ' -DSERF_HAVE_GSSAPI -g'

    self.MODE = 644

  def load_deps(self):
    self.deps = { }

    hdrs = [File(dirpath, fname, 'h') for dirpath, fname in FILES_HDR]
    libfiles = [File(dirpath, fname, 'c') for dirpath, fname in LIB_FILES]
    libobjs = [File(dirpath, fname, 'lo') for dirpath, fname in LIB_FILES]
    for src, obj in zip(libfiles, libobjs):
      self._add_compile(src, obj, hdrs)

    self.hdrs = hdrs

    all_libs = self.LIBS + ' ' + self.SSL_LIBS

    lib = File('.', LIBNAME, 'la')
    cmd = '%s --silent --mode=link %s %s -rpath %s -o %s %s %s' % (
      self.LIBTOOL, self.CC, self.LDFLAGS, self.libdir,
      lib.fname, ' '.join([l.fname for l in libobjs]), all_libs)
    self._add_dep(lib, libobjs, cmd)

    # load the test program dependencies now
    testhdrs = copy.deepcopy(hdrs)
    testhdrs += [File(dirpath, fname, 'h') for dirpath, fname in TEST_HDR_FILES]
    testdeps = [File(dirpath, fname, 'c') for dirpath, fname in TEST_DEPS]
    testobjs = [File(dirpath, fname, 'lo') for dirpath, fname in TEST_DEPS]

    for testsrc, testobj in zip(testdeps, testobjs):
      self._add_compile(testsrc, testobj, testhdrs)

    for dirpath, fname in TEST_FILES:
      src = File(dirpath, fname, 'c')
      obj = File(dirpath, fname, 'lo')
      prog = File(dirpath, fname, None)

      self._add_compile(src, obj, hdrs)

      # test_all requires extra dependencies
      if fname == "test_all":
        cmd = '%s --silent --mode=link %s %s -static -o %s %s %s %s' % (
          self.LIBTOOL, self.CC, self.LDFLAGS,
          prog.fname, lib.fname, ' '.join([l.fname for l in [obj] + testobjs]), 
          all_libs)
        self._add_dep(prog, [lib, obj] + testobjs, cmd)
      else:
        cmd = '%s --silent --mode=link %s %s -static -o %s %s %s %s' % (
          self.LIBTOOL, self.CC, self.LDFLAGS,
          prog.fname, lib.fname, obj.fname, all_libs)
        self._add_dep(prog, [lib, obj], cmd)

    # create 'serf-1.pc' if it doesn't exist.
    pcfile = File('.', PCFILE, 'pc')
    self._add_dep(pcfile, [], self._write_pcfile)

  def _add_compile(self, src, obj, hdrs):
    cmd = '%s --silent --mode=compile %s %s %s %s -c -o %s %s' % (
      self.LIBTOOL, self.CC, self.CFLAGS, self.CPPFLAGS, self.INCLUDES,
      obj.fname, src.fname)
    self._add_dep(obj, [src] + hdrs, cmd)

  def _add_dep(self, target, deps, cmd):
    if target.mtime:
      for dep in deps:
        if dep in self.deps or (dep.mtime and dep.mtime > target.mtime):
          # a dep is newer. this needs to be rebuilt.
          break
      else:
        # this is up to date. don't add it to the deps[] structure.
        return
    # else non-existent, so it must be rebuilt.

    # Commands that are strings are cmdline invocations. Otherwise, it
    # should be a callable.
    if isinstance(cmd, str):
      cmd = CommandLine(cmd)

    # register the dependency so this will get built
    self.deps[target] = deps, cmd

  def _write_pcfile(self):
    """Generating serf-1.pc ..."""

    open(PCFILE + '.pc', 'w').write(
"""SERF_MAJOR_VERSION=%d
prefix=%s
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${prefix}/include/%s

Name: serf
Description: HTTP client library
Version: %s
Requires.private: libssl libcrypto
Libs: -L${libdir} -lserf-${SERF_MAJOR_VERSION}
Libs.private: %s
Cflags: -I${includedir}
""" % (MAJOR, self.prefix, INCLUDES, get_version(), self.LIBS))

  def build_target(self, target, dry_run):
    deps, cmd = self.deps.get(target, (None, None))
    if cmd is None:
      # it's already up to date. all done.
      return

    for f in deps:
      subdep = self.deps.get(f)
      if subdep:
        self.build_target(f, dry_run)

    # build the target now
    print(cmd.__doc__)
    if not dry_run:
      result = cmd()
      if result:
        raise BuildError(cmd.__doc__, result)
      # FALLTHROUGH

    # it's a dry run. pretend we built the target.
    del self.deps[target]
    return 0

  def install_target(self, target, dry_run):
    self.build_target(target, dry_run)

    # install the target now
    if not dry_run:

      for path in (self.libdir, self.pkgconfigdir, self.includedir):
        if not os.path.exists(path):
          try:
            os.makedirs(path)
          except OSError:
            raise BuildError('os.makedirs',
                             'can not create install directories')

      for f in self.hdrs:
        print("Installing: %s" % (os.path.basename(f.fname),))
        shutil.copy(f.fname, self.includedir)

      print("Installing: %s.pc" % (PCFILE,))
      shutil.copy(PCFILE + '.pc', self.pkgconfigdir)

      cmd = '%s --silent --mode=install %s -c -m %d %s %s' % (
            self.LIBTOOL, '/usr/bin/install', self.MODE, target.fname,
            self.libdir)

      print("Installing: %s" % (os.path.basename(target.fname),))
      result = os.system(cmd)
      if result:
        raise BuildError(cmd, result)
      # FALLTHROUGH

    return 0


class ConfigScript(object):
  script_name = None
  locations = [
    '/usr/bin',
    '/usr/local/bin',
    '/usr/local/apache2/bin',
    ]

  def __init__(self, search_dir):
    if search_dir:
      locations = [search_dir, os.path.join(search_dir, 'bin')]
    else:
      locations = self.locations

    for dirname in locations:
      bin = os.path.join(dirname, self.script_name)
      if os.access(bin, os.X_OK):
        self.bin = bin
        break
    else:
      raise ConfigScriptNotFound(self.script_name)

  def get_value(self, env_name, switch):
    if env_name and os.getenv(env_name):
      return os.getenv(env_name)
    return os.popen('%s %s' % (self.bin, switch), 'r').read().strip()


class APRConfig(ConfigScript):
  script_name = 'apr-1-config'


class APUConfig(ConfigScript):
  script_name = 'apu-1-config'


class GSSAPIConfig(ConfigScript):
  script_name = 'krb5-config'


class CommandLine(object):
  """Simple helper to invoke a system command when called."""

  def __init__(self, cmd):
    self.cmd = cmd
    self.__doc__ = cmd  # when we print the execution of this command

  def __call__(self):
    return os.system(self.cmd)


class File:
  def __init__(self, dirpath, fname, ext):
    if ext:
      self.fname = os.path.join(dirpath, fname + '.' + ext)
    else:
      self.fname = os.path.join(dirpath, fname)

    try:
      s = os.stat(self.fname)
    except OSError:
      self.mtime = None
    else:
      self.mtime = s[stat.ST_MTIME]

  def __eq__(self, other):
    return self.fname == other.fname

  def __hash__(self):
    return hash(self.fname)


def get_version():
  match = re.search('SERF_MAJOR_VERSION ([0-9]+).*'
                    'SERF_MINOR_VERSION ([0-9]+).*'
                    'SERF_PATCH_VERSION ([0-9]+)',
                    open('serf.h').read(),
                    re.DOTALL)
  major, minor, patch = match.groups()
  return '%s.%s.%s' % (major, minor, patch)


class BuildError(Exception):
  "An error occurred while building a target."
class TestError(Exception):
  "An error occurred while running a unit test."
class ConfigScriptNotFound(Exception):
  def __init__(self, value):
    self.value = "ERROR: A configuration script was not found: " + value
  def __str__(self):
    return self.value


if __name__ == '__main__':
  main(sys.argv)


###
### TODO:
### * obey DESTDIR
### * arfrever says LDFLAGS is passed twice
### * be able to specify libdir and includedir
###
